API Design Patterns — 13장 교차 참조 딥다이브 학습 노트

출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/


13장에서 다루는 내용

  • 다른 리소스를 참조하기 위해 사용되는 이름 필드 — authorId, targetId 등
  • 참조 필드가 참조 무결성 문제를 다루는 방법 — 허상 포인터, 삭제 대응
  • 필드가 리소스 데이터 복사본을 저장하는 시점과 그 이유 — 값 대 참조
  • 동적 리소스 타입 참조 — 여러 타입을 가리킬 수 있는 참조

전체 흐름도

[리소스 간 관계]                   [참조 방식]                    [무결성 처리]
    |                                |                              |
    v                                v                              v
+------------------+     +---------------------+     +---------------------------+
| Book 리소스가     |     | 참조 필드            |     | 13.3.2 데이터 무결성       |
| Author를 가리켜야 |---->| authorId: string     |---->| 허상 포인터 대응 3가지:   |
| 한다              |     | (고유 식별자 저장)    |     | 1. 삭제 금지             |
+------------------+     +---------------------+     | 2. 참조 필드 0으로 설정   |
                           |           |              | 3. 유효하지 않은 참조 허용|
                           v           v              +---------------------------+
                    +----------+ +----------+                  |
                    | 정적 참조| | 동적 참조|         +---------------------------+
                    | Book →   | | targetId |         | 13.3.3 값 대 참조         |
                    | Author   | | +        |         | 참조(포인터): 항상 최신    |
                    | (고정)   | | targetType|        | 값(복사본): 스냅샷        |
                    +----------+ +----------+         +---------------------------+

선수 지식 체크리스트

  • [ ] REST API 기본 개념 (리소스, 엔드포인트)
  • [ ] 표준 메서드 (Get, Create, Delete)
  • [ ] 고유 식별자 개념 (6장)

핵심 키워드

키워드 의미
교차 참조 한 리소스가 다른 리소스를 가리키는 것
참조 필드 다른 리소스의 고유 식별자를 저장하는 문자열 필드 (예: authorId)
허상 포인터 삭제된 리소스를 가리키는 유효하지 않은 참조
동적 리소스 타입 참조 대상의 타입이 상황에 따라 달라질 수 있는 참조
값 대 참조 데이터의 복사본을 저장할지(값), 포인터만 저장할지(참조) 선택
순환 참조 두 리소스가 서로를 참조하는 상태

13.1 서론 — 왜 리소스가 다른 리소스를 참조해야 하는가?

한 줄 요약

리소스가 홀로 존재하는 경우는 거의 없다. 다른 리소스를 가리키는 방법이 필요하다!

쉬운 설명 (도서관 비유)

도서관에서 책(Book)과 저자(Author)를 관리한다고 하자.

"이 책은 누가 썼는지 어떻게 기록할까?"

  • 방법 1: 책 카드에 저자 이름을 직접 적는다 → 저자가 이름을 바꾸면? 모든 책 카드를 수정해야!
  • 방법 2: 책 카드에 "저자 번호: A-123"만 적고, 저자 정보는 저자 카드에서 찾는다 → 이게 교차 참조!
[Book 카드]                    [Author 카드]
┌──────────────────┐          ┌──────────────────┐
│ id: "books/1234" │          │ id: "authors/1"  │
│ title: "해리포터"│          │ name: "J.K.롤링" │
│ authorId: ───────┼─────────>│                  │
│   "authors/1"    │          └──────────────────┘
└──────────────────┘

실무 예제 — 참조가 필요한 다양한 상황

온라인 쇼핑몰:
Order (주문) → Customer (고객)         : customerId
Order (주문) → Product (상품)          : productId
Review (리뷰) → Product (상품)         : productId
Review (리뷰) → Customer (고객)        : authorId

블로그 플랫폼:
Post (게시글) → Author (작성자)        : authorId
Comment (댓글) → Post (게시글)         : postId
Comment (댓글) → Author (작성자)       : authorId

CI/CD 시스템:
Build (빌드) → Repository (저장소)     : repositoryId
Build (빌드) → Commit (커밋)           : commitId
Deployment (배포) → Build (빌드)       : buildId

참조의 범위 3가지

┌─────────────────────────────────────────────────────────────────┐
│ 범위 1: 로컬 참조 (같은 API 내)                                  │
│ Book → Author  (books API 안에서 다른 리소스 참조)               │
│ 가장 일반적, 관리가 쉬움                                         │
├─────────────────────────────────────────────────────────────────┤
│ 범위 2: 같은 서비스 제공자 내 (다른 API)                          │
│ Order → PaymentTransaction (주문 API → 결제 API)                │
│ 같은 회사라 신뢰 가능, 하지만 API 변경 시 영향 있음               │
├─────────────────────────────────────────────────────────────────┤
│ 범위 3: 전역 참조 (외부 서비스)                                   │
│ Book → ISBN (도서 API → 국제 ISBN 데이터베이스)                   │
│ 표준 URI 사용, 외부 서비스 장애 시 참조 확인 불가                  │
└─────────────────────────────────────────────────────────────────┘

핵심 체크포인트

  • ✅ 리소스는 거의 항상 다른 리소스와 관계를 가진다
  • ✅ 참조는 고유 식별자(문자열)를 사용한다
  • ✅ 참조와 가리키는 리소스는 완전히 분리되어 있다
  • ✅ 범위가 넓어질수록(로컬→전역) 관리가 복잡해진다

13.2 개괄 — 교차 참조 패턴의 기본 원리

한 줄 요약

참조 속성에 다른 리소스의 고유 식별자(문자열)를 저장하고, 필요하면 그 식별자로 해당 리소스를 조회한다.

Java/DB 비유

// Java에서의 참조 — 객체 포인터
class Book {
    String id;
    String title;
    Author author;     // ← 객체 참조 (메모리 주소)
}

// API에서의 참조 — 문자열 식별자
interface Book {
    id: string;
    title: string;
    authorId: string;  // ← 문자열 참조 ("authors/1")
}
// 실제 Author 데이터가 필요하면 별도 API 호출: GET /authors/1

장점과 단점 상세

장점 상세 설명
유연성 어떤 리소스든 식별자만 있으면 참조 가능
순환 참조 안전 포인터만 저장하므로 객체가 잠기지 않음
확장성 하나의 리소스가 수천 개의 다른 리소스를 참조 가능
분리 참조하는 리소스와 참조되는 리소스가 완전히 독립적
단점 상세 설명
추가 API 호출 참조 데이터를 보려면 별도 GET 요청 필요
허상 포인터 참조 대상이 삭제되면 유효하지 않은 참조가 남음
타입 안전성 없음 문자열이므로 잘못된 ID를 저장해도 컴파일 에러 없음

핵심 체크포인트

  • ✅ 참조는 문자열 식별자로 저장 (타입 안전성 없음)
  • ✅ 참조는 가리키는 리소스와 독립적
  • ✅ 참조 대상이 이동/삭제될 수 있음을 항상 고려

13.3 구현

13.3.1 참조 필드 이름

한 줄 요약

참조 필드 이름으로 타입과 목적을 전달한다. 정적 참조(authorId)와 동적 참조(targetId + targetType)가 있다.

정적 리소스 타입 참조

// Book이 항상 Author를 참조 (정적)
interface Book {
    id: string;
    authorId: string;    // ← "authors/1" 형태의 Author 고유 식별자
    title: string;
    publisherId: string; // ← 출판사 참조도 동일한 패턴
}

interface Author {
    id: string;          // ← authorId에 들어가는 값
    name: string;
}

네이밍 규칙: 참조대상타입 + Id → authorId, publisherId, categoryId

실무 예제 — 다양한 정적 참조

// 온라인 쇼핑몰의 주문
interface Order {
    id: string;               // "orders/ord-123"
    customerId: string;       // "customers/cust-456"  ← 누가 주문했는가
    shippingAddressId: string;// "addresses/addr-789"  ← 어디로 배송하는가
    paymentMethodId: string;  // "payments/pm-012"     ← 어떻게 결제하는가
    items: OrderItem[];       // 주문 항목 (인라인)
}

interface OrderItem {
    productId: string;        // "products/prod-345"   ← 어떤 상품인가
    quantity: number;
    unitPrice: number;
}

// 참조 체인: Order → Customer, Address, PaymentMethod, Product
// 주문 하나가 4종류의 다른 리소스를 참조!

동적 리소스 타입 참조

// 변경 이력 — 어떤 타입의 리소스든 참조 가능 (동적)
interface ChangeLogEntry {
    id: string;
    targetId: string;       // ← "authors/1" 또는 "books/234" 또는 "publishers/5"
    targetType: string;     // ← "Author" 또는 "Book" 또는 "Publisher"
    action: string;         // "created" | "updated" | "deleted"
    timestamp: DateTime;
    description: string;
}

// 사용 예시
{
    id: "logs/1",
    targetId: "authors/1",
    targetType: "Author",
    action: "updated",
    timestamp: "2024-03-15T09:00:00Z",
    description: "이름이 'J.K. Rowling'에서 'Joanne Rowling'으로 변경됨"
}

{
    id: "logs/2",
    targetId: "books/234",
    targetType: "Book",
    action: "created",
    timestamp: "2024-03-15T10:30:00Z",
    description: "새 책 '해리포터와 마법사의 돌' 생성됨"
}

왜 동적 참조? 감사 로그, 변경 이력, 알림 시스템 등에서 모든 타입의 리소스를 기록해야 할 때.

정적 참조 vs 동적 참조 비교

항목 정적 참조 동적 참조
필드 수 1개 (authorId) 2개 (targetId + targetType)
대상 타입 항상 고정 상황에 따라 변경
사용 시점 대부분의 경우 감사 로그, 알림, 변경 이력
타입 안전성 약간 있음 (필드명으로 추측) 없음 (targetType 확인 필요)
복잡도 낮음 중간

13.3.2 데이터 무결성

한 줄 요약

참조 대상이 삭제되면 허상 포인터가 생긴다. 3가지 대응 방식이 있으며, 허용이 권장된다.

허상 포인터 문제 — 실무 시나리오

// === 시나리오: 저자 삭제 후 책의 참조가 깨지는 과정 ===

// 1단계: 저자 생성
POST /authors { name: "Michelle Obama" }
→ { id: "authors/1", name: "Michelle Obama" }

// 2단계: 그 저자를 참조하는 책 생성
POST /books { title: "Becoming", authorId: "authors/1" }
→ { id: "books/234", title: "Becoming", authorId: "authors/1" }

// 3단계: 저자 삭제!
DELETE /authors/1
→ 204 No Content

// 4단계: 책 조회 — authorId가 여전히 "authors/1"을 가리킴
GET /books/234
→ { id: "books/234", title: "Becoming", authorId: "authors/1" }

// 5단계: 해당 저자를 조회하면?
GET /authors/1
→ 404 Not Found  ← 허상 포인터!

// 클라이언트가 이 상황을 처리해야 함

3가지 대응 방식 상세

┌──────────────────────────────────────────────────────────────┐
│ 방식 1: 삭제 금지 (Restrict)                                  │
│                                                              │
│ DELETE /authors/1                                            │
│ → 409 Conflict "이 저자를 참조하는 책이 있습니다"             │
│                                                              │
│ 장점: 허상 포인터 완전 방지                                    │
│ 단점:                                                        │
│  - 삭제 전 모든 참조를 먼저 제거해야 함                        │
│  - 순환 참조 시 아무것도 삭제 불가! (교착 상태)                │
│    예: Book의 authorId → Author                              │
│        Author의 favoriteBookId → Book                        │
│    → Author를 삭제하려면 Book의 참조를 먼저 제거               │
│    → Book을 삭제하려면 Author의 참조를 먼저 제거               │
│    → 둘 다 삭제 불가!                                         │
│                                                              │
│ 적합한 경우: 순환 참조가 없고, 참조 수가 적은 시스템            │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ 방식 2: 삭제 허용 + 참조 필드 재설정 (Set Null)               │
│                                                              │
│ DELETE /authors/1                                            │
│ → 저자 삭제됨                                                 │
│ → books/234의 authorId가 자동으로 null로 변경됨               │
│ → books/567의 authorId도 자동으로 null로 변경됨               │
│ → ... (이 저자의 모든 책이 업데이트됨)                        │
│                                                              │
│ 장점: 허상 포인터 방지                                         │
│ 단점:                                                        │
│  - 수천 권의 책을 업데이트해야 할 수 있음 (느림)              │
│  - 원자적 처리 불가능 (중간에 일부만 업데이트된 상태)          │
│  - delete가 "부수 효과 없음" 원칙에 위배                      │
│                                                              │
│ 적합한 경우: 참조 수가 적고, 일관성이 중요한 소규모 시스템      │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ 방식 3: 삭제 허용 + 허상 포인터 허용 (권장!) ✅               │
│                                                              │
│ DELETE /authors/1                                            │
│ → 저자 삭제됨                                                 │
│ → books/234의 authorId는 여전히 "authors/1" (그대로 유지)     │
│                                                              │
│ 장점:                                                        │
│  - 가장 단순하고 일관적                                       │
│  - delete 메서드의 원자성과 부수효과 없음 원칙 준수            │
│  - 어떤 규모에서든 동일하게 동작                               │
│                                                              │
│ 단점:                                                        │
│  - 클라이언트가 참조를 항상 확인해야 함                        │
│  - 허상 포인터가 시간이 지나면 쌓일 수 있음                    │
│                                                              │
│ 클라이언트 처리 패턴:                                         │
│  let book = GetBook({ id: "books/234" });                    │
│  try {                                                       │
│      let author = GetAuthor({ id: book.authorId });          │
│      showBookWithAuthor(book, author);                       │
│  } catch (NotFoundError) {                                   │
│      showBookWithoutAuthor(book);  // 저자 없이 표시          │
│  }                                                           │
│                                                              │
│ 적합한 경우: 대부분의 시스템 (기본 선택)                       │
└──────────────────────────────────────────────────────────────┘

순환 참조 시나리오

Book {
    id: "books/1",
    authorId: "authors/1"        // → Author를 참조
}

Author {
    id: "authors/1",
    favoriteBookId: "books/1"    // → Book을 참조
}

# 방식 1(삭제 금지)을 사용하면:
# Author를 삭제하려면 → Book의 authorId를 먼저 제거
# Book을 삭제하려면 → Author의 favoriteBookId를 먼저 제거
# 둘 다 삭제 불가! → 교착 상태(deadlock)!
#
# 이것이 방식 3(허상 허용)이 권장되는 핵심 이유

13.3.3 값 대 참조

한 줄 요약

참조(포인터)는 항상 최신 데이터를 보장하고, 값(복사본)은 추가 API 호출 없이 바로 사용 가능하다.

참조 방식 (pass by reference) — 기본

// 책의 저자 이름을 알려면 2번의 API 호출 필요
let book = GetBook({ id: "books/1234" });
// book = { id: "books/1234", title: "Becoming", authorId: "authors/1" }

let author = GetAuthor({ id: book.authorId });
// author = { id: "authors/1", name: "Michelle Obama" }

console.log(`${book.title} by ${author.name}`);
// "Becoming by Michelle Obama"

값 방식 (pass by value) — 인라인 복사본

// 책 리소스에 저자 정보를 복사본으로 포함
interface BookWithInlineAuthor {
    id: string;
    title: string;
    authorId: string;       // ← 참조 (포인터)
    author: {               // ← 복사본 (스냅샷, id 없음!)
        name: string;
        // id를 포함하지 않음! (원본과 혼동 방지)
    };
}

// 1번의 API 호출로 충분
let book = GetBook({ id: "books/1234" });
// book = {
//     id: "books/1234",
//     title: "Becoming",
//     authorId: "authors/1",
//     author: { name: "Michelle Obama" }   ← 복사본!
// }

console.log(`${book.title} by ${book.author.name}`);
// "Becoming by Michelle Obama" — 추가 API 호출 없음!

인라인 복사본에 id를 생략해야 하는 이유

// ❌ 잘못된 설계: 복사본에 id 포함
interface Book {
    id: string;
    author: {
        id: string;         // ← id가 있으면 클라이언트가 혼동!
        name: string;
    };
}

// 클라이언트가 이렇게 잘못 사용할 수 있음:
UpdateAuthor({ id: book.author.id, name: "New Name" });
// 이게 되는 건가? 복사본을 수정하면 원본도 바뀌는 건가? → 혼란!

// ✅ 올바른 설계: 복사본에 id 생략
interface Book {
    id: string;
    authorId: string;       // ← 원본 참조 (수정하려면 이걸로 접근)
    author: {
        name: string;       // ← 복사본 (읽기 전용 스냅샷)
    };
}

서버 측 구현 전략

전략 1: 매 요청마다 DB JOIN으로 최신 데이터 취득
┌────────────────────────────────────────────┐
│ GET /books/1234                            │
│ → SELECT b.*, a.name as author_name        │
│   FROM books b                             │
│   JOIN authors a ON b.author_id = a.id     │
│   WHERE b.id = 'books/1234'               │
│                                            │
│ 장점: 항상 최신 저자 이름                   │
│ 단점: 매 요청마다 JOIN → DB 부하 증가       │
└────────────────────────────────────────────┘

전략 2: 리소스 내에 캐시 복사본 저장
┌────────────────────────────────────────────┐
│ books 테이블:                              │
│ { id, title, author_id, author_name_cache }│
│                                            │
│ GET /books/1234                            │
│ → author_name_cache에서 바로 반환           │
│                                            │
│ Author 업데이트 시:                         │
│ → 해당 저자의 모든 책의 캐시도 업데이트      │
│                                            │
│ 장점: 빠름 (JOIN 불필요)                    │
│ 단점: 캐시 불일치 위험                      │
└────────────────────────────────────────────┘

비교 정리

항목 참조 (포인터) 값 (복사본)
API 호출 수 2번 (Book + Author) 1번 (Book에 포함)
데이터 최신성 항상 최신 취득 시점 스냅샷
응답 크기 작음 큼 (포함된 데이터만큼)
서버 복잡도 낮음 높음 (캐시/JOIN 관리)
업데이트 혼란 없음 Book Update 시 author도 수정 가능한지 혼란

권장

참조(포인터) 방식이 기본. 복사본은 응답 크기 증가와 데이터 일관성 문제를 야기하므로, 꼭 필요한 경우에만 사용. 대안으로 GraphQL을 활용하면 한 번의 쿼리로 참조 데이터까지 취득 가능.

13.3.4 최종 API 정의

abstract class BookApi {
    static version = "v1";

    // === Author 표준 메서드 ===
    @post("/authors")
    CreateAuthor(req: CreateAuthorRequest): Author;

    @get("/{id=authors/*}")
    GetAuthor(req: GetAuthorRequest): Author;

    @get("/authors")
    ListAuthors(req: ListAuthorsRequest): ListAuthorsResponse;

    @patch("/{resource.id=authors/*}")
    UpdateAuthor(req: UpdateAuthorRequest): Author;

    @delete("/{id=authors/*}")
    DeleteAuthor(req: DeleteAuthorRequest): void;

    // === Book 표준 메서드 ===
    @post("/books")
    CreateBook(req: CreateBookRequest): Book;

    @get("/{id=books/*}")
    GetBook(req: GetBookRequest): Book;

    @get("/books")
    ListBooks(req: ListBooksRequest): ListBooksResponse;

    @patch("/{resource.id=books/*}")
    UpdateBook(req: UpdateBookRequest): Book;

    @delete("/{id=books/*}")
    DeleteBook(req: DeleteBookRequest): void;
}

// 정적 참조 — 항상 Author를 가리킴
interface Book {
    id: string;            // "books/1234"
    authorId: string;      // ← 정적 참조 필드 (Author의 id)
    title: string;
}

// 참조 대상
interface Author {
    id: string;            // "authors/1"
    name: string;
}

// 동적 참조 — 어떤 타입이든 가리킬 수 있음
interface ChangeLogEntry {
    id: string;            // "logs/1"
    targetId: string;      // ← 동적 참조 (대상 식별자)
    targetType: string;    // ← 대상 타입 ("Author", "Book" 등)
    action: string;        // "created" | "updated" | "deleted"
    description: string;
}

핵심 체크포인트

  • ✅ 참조 필드 이름: 대상타입 + Id (authorId, targetId)
  • ✅ 동적 참조: targetId + targetType 조합
  • ✅ 허상 포인터: 삭제 허용 + 클라이언트가 확인 (방식 3 권장)
  • ✅ 기본은 참조(포인터), 복사본은 신중히
  • ✅ 인라인 복사본에 id 필드를 포함하지 않음

13.4 트레이드오프

한 줄 요약

참조 방식의 주된 트레이드오프는 연관 정보를 얻기 위해 복수의 API 호출이 필요하다는 것.

# 책과 저자 이름을 함께 보여주려면?

# REST 방식: 2번 호출
GET /books/1234           → { authorId: "authors/1", title: "..." }
GET /authors/1            → { name: "Michelle Obama" }

# GraphQL 방식: 1번 호출
query {
    book(id: "books/1234") {
        title
        author {              ← 참조를 자동으로 풀어줌!
            name
        }
    }
}
→ { title: "Becoming", author: { name: "Michelle Obama" } }

핵심: REST에서 참조의 단점(추가 호출)은 GraphQL이나 BFF 패턴으로 보완 가능.


13.5 연습문제 풀이

Q1: 외래 리소스 데이터의 복사본을 저장하는 것이 참조보다 더 유리한 경우는?

A: 참조 대상이 거의 변경되지 않고, 매번 추가 API 호출의 비용이 큰 경우. 예: 국가 코드, 통화 정보, 시간대 등 거의 불변인 참조 데이터. 또는 모바일 앱에서 오프라인 지원이 필요한 경우.

Q2: API 시스템에서 참조 무결성을 유지하는 것이 가능하지 않은 이유는?

A: 분산 시스템에서 원자적 트랜잭션을 보장하기 어렵기 때문. 참조 대상 삭제 시 모든 참조를 동시에 업데이트하는 것은 대규모 시스템에서 사실상 불가능하다. 또한 delete 메서드의 "부수 효과 없음" 원칙에 위배된다.

Q3: 모든 참조를 최신으로 유지하던 API가 그 규칙을 포기하면 위험한 이유는?

A: 기존 클라이언트가 "참조가 항상 유효하다"는 전제로 코드를 작성했기 때문. 갑자기 허상 포인터가 허용되면 기존 클라이언트에서 NullPointerException 등 예상치 못한 에러가 발생한다. API 계약(contract)이 변경된 것이므로 breaking change에 해당.

Q4: 참조 필드와 복사본 필드를 어떻게 구분하는가?

A: 참조 필드: authorId: string (접미사 Id 사용). 복사본 필드: author: { name: string } (Id 없이 타입명 사용, id 필드 생략). 이 네이밍 규칙으로 클라이언트가 참조인지 복사본인지 바로 구분할 수 있다.


부록 A: 용어 사전

용어 영문 의미
교차 참조 Cross Reference 한 리소스가 다른 리소스의 식별자를 저장하여 가리키는 것
허상 포인터 Dangling Pointer 삭제된 리소스를 가리키는 유효하지 않은 참조
고아 레코드 Orphaned Record 참조 대상이 사라진 레코드
순환 참조 Circular Reference 두 리소스가 서로를 참조하는 상태
동적 리소스 타입 Dynamic Resource Type 참조 대상의 타입이 상황에 따라 달라지는 참조
참조 무결성 Referential Integrity 모든 참조가 유효한 대상을 가리키는 상태
인라인 복사본 Inline Copy 참조 대신 데이터를 직접 포함하는 방식 (스냅샷)
BFF 패턴 Backend For Frontend 프론트엔드 요구에 맞춰 여러 API 호출을 합치는 중간 레이어

부록 B: 핵심 비교표

참조 vs 값 비교

항목 참조 (포인터) 값 (복사본)
저장 식별자만 저장 전체 데이터 저장
최신성 항상 최신 스냅샷 (취득 시점)
API 호출 추가 호출 필요 한 번에 모두 취득
응답 크기 작음
일관성 자동 보장 수동 관리 필요
필드 네이밍 authorId (Id 접미사) author (Id 없음)

허상 포인터 대응 비교

방식 허상 포인터 순환 참조 대규모 적합 부수 효과 권장
삭제 금지 없음 ❌ 교착상태 없음 ⚠️
참조 재설정 없음 ❌ 느림 ✅ 있음 ⚠️
허상 허용 있음 없음 ✅ 권장

부록 C: 추천 참고 자료 & 링크

자료 설명
6장 리소스 식별자 고유 식별자 체계
14장 연관 리소스 다대다 관계의 명시적 모델링
15장 add/remove 다대다 관계의 간소화된 모델링
GraphQL 복수 참조를 한 번에 해결하는 쿼리 언어
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플